Zig

Impressions

  • (2025-03-30)

Negative points

  • The LSP is TERRIBLE :

    • .

      • The parameters of readAllAlloc  are: self , allocator  and size .

      • The parameters shown by the inlay hint are wrong, because it considers the first parameter to be self , when in fact that is passed implicitly since readAllAlloc  is a method of a struct.

      • stat  wasn't unwrapped in this case, so it will cause an error when trying to do stat.size

        • In other words, it never informs me that I'm forgetting to do error handling.

    • It keeps showing me errors that have already been resolved after compiling and getting an error.

      • Even after fixing the error and even after restarting the LSP, it stays stuck showing old errors and ignoring the new ones.

  • Zig output messages are horrible and confusing .

  • Anonymous Structs are unpleasant .

    • Safety :

      • I don't like how anonymous structs are called; it always feels like I'm dealing with some Lua object.

      • There is NO indicator that I'm forgetting to initialize some required struct parameter.

        • The LSP doesn't try to infer which anonymous struct I'm referring to, to give me information whether things are correct.

        • Maybe that's very hard, since the struct is completely anonymous and could mean anything.

    • I don't like the syntax :

      • The declaration is done like this:

        const Player = struct {
            pos: rl.Vector2,
            vel: rl.Vector2 = rl.Vector2.init(0, 0),
            aceleracao: f16,
            vel_max: f16,
            sprite: rl.Texture2D
        };
        
        • I find it confusing where the struct  keyword is placed.

          • I'm creating a TYPE, but the impression I get is that I'm just creating a random const  that receives an anonymous struct, but using the struct  keyword.

            • Maybe deep down that's what is happening, but it's odd. I want a type definition, not something "taken apart" like this.

          • In Odin structs are declared the same way, but in Odin the keyword  follows the same pattern as the declaration of proc :

            Vector2 :: struct {
                x: f32,
                y: f32,
            }
            
            • Proc in odin:

              multiply :: proc(x: int, y: int) -> int {
                  return x * y
              }
              
            • It follows the same structure name  + ::  (const) + keyword  (proc or struct) + syntax.

          • Zig seems undecided about it, such that it uses var + name , const + name  and fn + name  for some things, but decides to invert the syntax for struct: const + name + = + struct .

      • But instantiation is done like this:

        var player: Player = .{
            .pos = rl.Vector2.init(0, 0),
            .aceleracao = 100,
            .vel_max = 4,
            .sprite = player_sprite,
        };
        
        • I don't like how instantiation uses .  in front of the names.

          • I always forget to put them because it's just another small rule.

          • Maybe there is an argument about this, putting structs in a family similar to enums, but still it's annoying the difference between field definition and field access.

    • There are a zillion ways to instantiate an object :

      • Many of these ways result in bad behavior.

      • This is described in the Structs section.

  • Error handling :

    • Tiring :

      • try  ends up being used in many places, so all allocation operations use try , since they can return out-of-memory.

      • Frequent use of error handling increases the line length to be written, because I always have to:

        1. With try

          • Use try .

          • Write the huge function name coming from some package.

          • Pass the allocator as a function parameter.

        2. Use catch.

          • Write the huge function name coming from some package.

          • Pass the allocator as a function parameter.

          • Use a catch with a huge syntax while defining what to do in case of error.

          • Considering this results in the program not crashing: I have to deal with all local memory deallocations using errdefer .

      • Maybe in Go/Odin it's ok, idk, maybe it's tiring there too.

        • Though in Go or Odin errors seem more "soft", since they are just values, but in Zig it's much more "collapsing" and rigid, always feeling like asserts.

      • It's necessary that I watch if the function return has !  in Zig.

        • In Go or Odin I must watch if it's a double return, so the second return can be an error.

    • Using try everywhere is basically making an assert, but MUCH more confusing :

      • Under these conditions, try  doesn't actually provide error handling. The catch  provides error handling, in my opinion.

      • From what I see, try  is simply annoying.

      • Suppose what happens in Godot:

        • I try an operation that can return an object or null.

          • If it returns the object, OK.

          • If it returns null, then I may:

            1. Assert if the object is null, where it should never be null.

            2. Crash the code when trying to access some property or method of that object while it's null.

            3. Check if the object is null, clearly deal with the consequences, returning from the function or assigning a default value to the object.

        • So my options are absolutely clear.

      • THO, in Zig I feel there is a fourth option:
        4. Automatically return the function and propagate that error to a higher function.
        - Maybe that error is interpreted nicely in the upper functions, which is unlikely.
        - Maybe that error simply keeps being propagated infinitely up to the main loop, which stops execution by forcing the main function to return with an error code.
        - That is what happens in the vast majority of cases, since there's simply so much error handling happening in Zig, that 90% of them are try s, out of laziness.
        - In that case, it's basically an assert, but without guarantees that it will actually "assert" and stop the code. You need to traverse the whole call stack to find where error handling actually happens.

      • try  is: "I don't want to deal with this error, I will pass the responsibility to whoever receives my return".

        • As mentioned, try  is just a shortcut for x catch |err| return err , which describes exactly what I said, which I find confusing to work with.

  • The std is terrible to understand :

    • .

      • ArrayList :

        • It's a function that returns a type.

        • Calling ArrayList  is nothing more than calling ArrayListAligned , but without alignment.

          • Reflect on that....

          • I'll have to deal with the concept of Alignment, without using any Alignment....

      • ArrayListAligned .

        • It's a function that returns a type.

        • It's a public function that returns a type.

          • It first receives a type  T as a parameter and alignment information.

          • It receives a comptime  alignment, which is a ?u29 , that is, null  or a u29  (29 bytes).

            • Why is comptime   before  the parameter name? I find that confusing. I prefer something like alignment: comptime ?u29 .

        • Then the logic: if alignment  is not null and the alignment equals T's alignment, then call ArrayListAligned  again, but this time with alignment equal to null.

        • Then it finally returns a struct containing the type I want to define.

      • type ArrayListAligned(T, alignment)

        • Has 3 fields with no default value.

        • A pub const Slice  that is not accessible outside the struct.

          • I really have no idea why this field is not accessible.

          • The LSP doesn't give autocomplete and when trying to access ignoring the LSP I receive an error saying that this field doesn't exist, it's really strange.

        • A pub fn that returns a type SentinelSlice(s) .

        • A pub fn that returns a type, where this type is ArrayListAligned  itself, that is, a "constructor" function.

          • This returns the default values of each of the struct fields.

      • If the init  (constructor) of ArrayListAligned  only does this, why weren't these default values already defined in the struct, instead of needing a constructor?

        • It sounds like something done only by convention, but at the same time there were moments where this is not necessary or possible:

          • ,

          • DebugAllocator is ultra common to be used, it has a total of 863 lines of code, with 6 fields in the struct.

            • It DOES have a deinit() , but does not have an init() .

            • THO Fun Fact! Reading the comment at the top of the function, it describes that DebugAllocator  does have an init (lol).

              • If you look well at the screenshot, init  is actually a pub const init: Self = .{} .

              • .................bro what a mess.

          • Anyway, the way normally used is:

            • var debugAllocator = std.heap.DebugAllocator(.{}){};

            • or var debugAllocator = std.heap.DebugAllocator(.{}).init; , if you want to use Zig's "convention".

      • The annoying thing is that trying to follow a pattern in Zig always produces these weird things, because of dealing with anonymous structs without LSP.

      • Much of the complexity is due to OOP; methods are really a pain.

      • All packages I've encountered are written in OOP, using methods for everything. It's simply mega confusing.

      • In the end, I never know when I should initialize the struct with default values using anonymous structs, or if I should call some init function that assigns default parameters to the anonymous struct.

  • String manipulation is unpleasant :

    • I find dealing with low-level memory fun. The theory is very interesting and I like having control over these things.

    • BUT, I feel Zig makes the whole process a pain, because of how the tools are provided.

    • .

      • In this small segment, it was necessary to use:

        • String comparison.

          if (std.mem.eql(u8, info_tileset.get("identifier").?.string, "Internal_Icons")) continue;
          
          • I mean, wtf, it sounds absurd not being able to do:

            if (u8, info_tileset.get("identifier").?.string == "Internal_Icons") continue;      
            
            • In Odin it's possible to do that.

        • String concatenation:

          const pathCompleto: [:0]const u8 = try std.fmt.allocPrintZ(allocator, "{s}{s}", .{cwd, path});
          defer allocator.free(pathCompleto);
          
          • try

            • Obviously dealing with error handling

          • std.fmt.allocPrintZ

            • What a crappy syntax is that?

            • I have to access the standard library, the fmt package and use allocPrintZ .

              • I can't even use allocPrint , since I need the string to be null terminated.

          • allocator

            • I have to pass the allocator everywhere.

          • "{s}{s}"

            • Pass the string format, including s  to ensure it is interpreted as a string (as if that wouldn't already be obvious in this case).

          • .{cwd, path}

            • Anonymous struct without names (I find that very confusing).

              • Anonymous structs are very strange.

              • Sometimes I interpret them as hashmaps, but in this case I could interpret as an array, or hashmap with unnamed keys.

            • Despite the confusion with the struct, this part is OK.

          • free

            • I have to deallocate the memory.

          • Damn, it's a lot.

          • This could be simplified a lot.

            • The reason it's so long is because function allocPrintZ  or allocPrint  returns [:0]u8 , and not [:0]const u8 .

            • I believe if there were a function that returned [:0]const u8 , then there would be no need to use an allocator as a function parameter, nor to use free after creating the string.

              • Memory could be created inside the function, something like that, I don't understand memory well enough, but I believe it's possible to minimize problems by returning a string literal.

  • Print is annoying :

    • I have to use \n  every time, which I find annoying.

      • There was never a moment when I didn't need \n  in print, never.

  • Ifs and loops use ( ) :

    • It's quite annoying to have to write ( )  around the expressions of ifs and loops.

      • I always forget and have to go back to fix it.

    if (valor != 5) {
        return;
    }
    
    for (meu_array.items) |item| {
        std.debug.print("{any}", .{item});
    }
    
    
  • Unused parameters being an error is annoying :

    • Sometimes I just want to test something and see if it works, but every time I have to use _ = a;  or _ = .{a, b}; .

    • This is annoying after a while.

    • It also makes the code cluttered:

      • There will be extra lines of code that do absolutely nothing, they just take up space.

    • It also makes the code unsafe:

      • I would prefer it to be a warning, but this became NOTHING.

      • All information about the problem is lost, once I'm completely ignoring the problem.

    • In general, I don't like how Zig handles this; it's bad.

  • The using  keyword does nothing and will be removed :

    • This is not a big problem, since I don't find using  very welcome, but it would still be ok to have this option for helper functions.

  • Constant breaking changes that make using external libs a headache

    • Many libs use incompatible versions among themselves.

    • Many libs use nightly versions, on master; others use older versions because they're not being updated.

    • Many libs even use dev, unstable versions.

    • The problem happens when there are changes in the build system from one version to another, so that it's not possible to use the lib at all unless you download its source code and edit it.

  • The build.zig and build.zig.zon are extremely confusing and inconvenient :

    • The LSP doesn't work if all dependencies are not correct in build.zig and build.zig.zon.

      • That's a pain for new projects.

    • I tried to learn about build.zig and despite having understood how to configure what I wanted, it's simply very confusing still. It's not friendly for beginners.

    • Maybe the configurations are useful when you master what's happening, but it's unpleasant to poke at those files at the beginning.

  • Doesn't do default initialization to Zero Value :

    • Doing const flag: bool;  gives the error "Variables must be initialized".

      • This happens for everything.

    • Both Odin and GDScript do zero initialization for all types.

      • This is very nice, saves typing and makes the code safer by preventing trying to access an uninitialized property.

  • Doesn't have a context allocator / default allocator :

    • This can be seen as an advantage, but also as a disadvantage.

    • The disadvantage I see is having to carry the allocator everywhere in the code, leaving the code very loaded, always being "haunted" by memory management.

    • The advantage is the reinforcement that the only way to allocate something is by passing the allocator as a function parameter.

      • EXCEPT, that is false. It's possible to create allocators inside functions however you want. The above sentence is just a convention adopted by Zig.

      • It's a good convention, but still just a convention.

    • Anyway, I miss Odin's context , which saves me the work of dealing with allocators, but allows overriding the allocator used in the function if I want.

      • meu_procedure :: proc(allocator := context.allocator) {}

        • That's the convention in Odin; the default is the context's allocator, but one can use another allocator if desired.

  • I find mandatory ;  slightly annoying :

    • Odin doesn't force it and I never missed it at any time.

  • Zig's syntax is VERY confusing :

    • No matter how much I use Zig, I feel like the next day I'm very confused about how the syntax works.

    • The entire std is just very confusing and the syntax is full of things I need to have memorized.

    • I reached this conclusion after trying Odin and realizing how much easier and more intuitive Odin's syntax is.

      • I have considerably fewer doubts writing Odin, although I barely read Odin's documentation, while I devoured articles and articles about Zig.

    • It sounds strange how unintuitive Zig can be syntactically.

    • I feel Zig has some "fussy bits" that make the syntax one of the most confusing I've used.

      • Rust, Odin, Swift, Jai, etc.; are all syntactically more intuitive than Zig.

Positive points

  • The return of a JSON parse is ok :

    • The syntax below is ok:

      const defs = ldtkParsed.get("defs").?.object;
      const info_tilesets = defs.get("tilesets").?.array;
      const path = info_tileset.get("relPath").?.string;
      
  • Unions for Null Safety and Error Handling :

    • Cool.

    • ?  and !  are ok.

    • They are aspects of Functional Programming.

  • Pointers are nice :

    • The use of .*  to dereference is pretty nice, I liked it.

  • pub fn  is nice and simple .

  • Clarity about what is built-in using @ .

  • defer  working in scope is nice .

    • I have big doubts about this, though.

    • Go  makes defer  work at the end of the current function scope, which may be better for error handling.

      • I found it strange to use defer  inside if s, as it makes the call happen at the end of the if  scope, which is odd and impractical.

    • On the other hand, it's nice to create blocks with internal defers:

          {
              //something here
          }
      
  • Many things are expressions, instead of only statements :

    • I'm unsure whether I like this so far.

    const s = 'a';
    const valor: i32 = switch (s) {
        'a' => 1,
        'b' => 20,
        'c' => 5,
         _  => 0,
    };
    

About

Style Guidelines
  • snake_case

    • variables.

    • namespace.

  • camelCase

    • functions.

  • PascalCase

    • types.

HTTP Requests Example
const std = @import("std");
const http = @import("zig-http/http");

pub fn main() !void {
    const allocator = std.heap.page_allocator;

    // Create the HTTP server
    var server = try http.Server.init(allocator, "0.0.0.0", 8080, handler);

    std.debug.print("Server running at http://localhost:8080\n", .{});

    // Start the server
    try server.listen();
}

// Function that handles HTTP requests
fn handler(req: *http.Request, res: *http.Response) !void {
    // Process only GET requests
    if (req.method != .Get) {
        res.status = .MethodNotAllowed;
        try res.send("Method not allowed");
        return;
    }

    // Get the "name" parameter from the URL
    const name = req.getQueryParam("name") orelse "world";

    // Create the response message
    const message = try std.fmt.allocPrint(std.heap.page_allocator, "Hello, {}!", .{name});

    // Set status and response body
    res.status = .Ok;
    try res.send(message);

    // Free the memory allocated for the message
    std.heap.page_allocator.free(message);
}

Installation

Version
  • Use Scoop or download the standalone executable.

    • I placed the downloaded folder in C:\Users\caior\apps  and added it to the user path.

  • For dev versions, often it only works via the executable.

    • The exercises repo and the gamedev examples repo only use the dev version, so it makes more sense to use master right away.

  • I installed via winget .

VSCode
  • Installing Zig, the Zig extension in VSCode and the LSP in VSCode was smooth, no problem at all.

  • .

    • Apparently you have to do this for the debugger to work in vscode.

    • (2025-03-15) I did this, the same way as in the image.

Build

flags
  • --prefix  / -p

.zig-cache
  • Contains files that will make subsequent builds faster

  • These files are not intended to be checked into source-control and this directory can be completely deleted at any time with no consequences.

build.zig.zon
  • Documentation .

  • In my understanding, the file contains the pathing of dependencies to be downloaded in order to build the project.

  • When running

zig run
  • Compiles the specified source code directly into a temporary binary.

  • Runs the binary immediately after compilation.

  • Does not use the build system, that is, it doesn't process the configurations defined there.

Resolving version conflicts

Comparisons
  • 0.13.0 -> 0.14.0

    • Introduces problems in the .name  described in the build.zig.zon  file, so that names must be in the format .my_name , I think.

    • Although this can be changed in the project itself, all dependencies end up having this problem as well.

  • FunFacts!!

    • Some dependencies use a 0.14.0-dev version, so 0.13.0 is incompatible and 0.14.0 introduces problems with .name Coooool! =)

Running demos on different versions
  • Remember to use .  before the Zig path.

  • ."C:\Users\caior\apps\zig-windows-x86_64-0.14.0\zig.exe" build run

  • ."C:\Users\caior\apps\zig-windows-x86_64-0.13.0\zig.exe" build run

  • ."C:\Users\caior\apps\zig-windows-x86_64-0.12.1\zig.exe" build run

Basics

Operators
Keywords
Access Modifiers
  • Chat-gpt: " Default:  There is no native visibility control."

    • pub  is related to code organization and modularity, but is not tied to dynamic runtime security rules .

    • Zig uses modular organization to control access between different parts of the code.

    • Public ( pub ) : Used to expose functions, types or variables from a module to other modules.

    • Private (default) : Items defined without pub  are private to the current scope (file or block)."

Comments, Print, Formatting

Comments
//! Top-level documentation.

/// Documentation comment.

// Simple comment.
Suppress Warnings
//suppress unused constant compile error    
_ = .{ a, b, c, d };
Print
  • No automatic \n .

  • "Prints to stderr (it's a shortcut based on std.io.getStdErr() )".

  • Part of the standard library ( std ).

  • Uses std.debug.print() .

  • Writes directly to standard output ( stdout ).

  • Accepts string formatting, similar to printf  in C.

const std = @import("std");
const print = std.debug.print

pub fn main() void {
    print("Value: {}\n", .{42});
}
Info
  • With automatic \n .

  • Part of the std.log  module.

  • Uses std.log.info() .

  • Unlike print , info  can be filtered by log levels (like debug , warn , err ).

  • Output can be redirected or configured depending on the compiler and runtime environment.

const std = @import("std");

pub fn main() void {
    std.log.info("Value: {}", .{42});
}
Formatting
  • std.fmt  provides ways to format data to and from strings.

  • A basic example of creating a formatted string. The format string must be compile-time known. The d  here denotes that we want a decimal number.

const std = @import("std");
const expect = std.testing.expect;
const eql = std.mem.eql;
const test_allocator = std.testing.allocator;

test "fmt" {
    const string = try std.fmt.allocPrint(
        test_allocator,
        "{d} + {d} = {d}",
        .{ 9, 10, 19 },
    );
    defer test_allocator.free(string);

    try expect(eql(u8, string, "9 + 10 = 19"));
}
  • std.debug.print :

    • "it writes to stderr and is protected by a mutex."

    const std = @import("std");
    const expect = std.testing.expect;
    const eql = std.mem.eql;
    test "hello world" {
        const out_file = std.io.getStdOut();
        try out_file.writer().print(
            "Hello, {s}!\n",
            .{"World"},
        );
    }
    
  • Format Specifiers :

    • {s} : strings.

    • {d} : decimal.

      • {d:.2}

    • {c} : ascii character.

    • {*} : pointer formatting, printing the address rather than the value.

    • {any} : default formatting.

    • {e} : floats in scientific notation.

    • {b} : binary.

    • {o} : octal.

    • etc.

Functions

  • Functions .

  • All function arguments are immutable - if a copy is desired the user must explicitly make one.

  • Unlike variables, which are snake_case, functions are camelCase.

fn addFive(x: u32) u32 {
    return x + 5;
}

test "function" {
    const y = addFive(0);
}
Built-in Functions

Control Flow (if, while, for, switch, labelled, iterators)

If
const a = true;
var x: u16 = 0;
if (a) {
    x += 1;
} else {
    x += 2;
}
const a = true;
var x: u16 = 0;
x += if (a) 1 else 2;
  • "If it exists" :

    // Shortcut for "if (x) x else 0"
    var value = x orelse 0;
    
    // Get a pointer to the value (if it exists).
    if (a) |*value| { value.* += 1; }
    
While
var i: u8 = 2;    
while (i < 100) {        
    i *= 2;    
}
// Simple "while" loop.
while (i < 10) { i += 1; }

// While loop with a "continue expression"
// (expression executed as the last expression of the loop).
while (i < 10) : (i += 1) { ... }
// Same, with a more complex continue expression (block of code).
while (i * j < 2000) : ({ i *= 2; j *= 3; }) { ... }
var sum: u8 = 0;
var i: u8 = 1;
while (i <= 10) : (i += 1) {
    sum += i;
}
var sum: u8 = 0;
var i: u8 = 0;
while (i <= 3) : (i += 1) {
    if (i == 2) continue;
    sum += i;
}
var sum: u8 = 0;
var i: u8 = 0;
while (i <= 3) : (i += 1) {
    if (i == 2) break;
    sum += i;
}
  • Loops as Expressions :

    • Like return , break  accepts a value.

    • This can be used to yield a value from a loop.

    • Loops in Zig also have an else  branch, which is evaluated when the loop is not exited with a break .

    fn rangeHasNumber(begin: usize, end: usize, number: usize) bool {
        var i = begin;
        return while (i < end) : (i += 1) {
            if (i == number) {
                break true;
            }
        } else false;
    }
    
    test "while loop expression" {
        try expect(rangeHasNumber(0, 10, 3));
    }
    
For
// We've had to assign values to `_`, as Zig does not allow us to have unused values.
// Character literals are equivalent to integer literals
const string = [_]u8{ 'a', 'b', 'c' };

for (string, 0..) |character, index| {
    _ = character;
    _ = index;
}

for (string) |character| {
    _ = character;
}

for (string, 0..) |_, index| {
    _ = index;
}

for (string) |_| {}
// To iterate over a portion of a slice, reslice.
for (items[0..1]) |value| { sum += value; }

// Loop over every item of an array (or slice).
for (items) |value| { sum += value; }

// Iterate and get pointers on values instead of copies.
for (items) |*value| { value.* += 1; }

// Iterate with an index.
for (items) |value, i| { print("val[{}] = {}\n", .{i, value}); }

// Iterate with pointer and index.
for (items) |*value, i| { print("val[{}] = {}\n", .{i, value}); value.* += 1; }
// Break and continue are supported.
for (items) |value| {
    if (value == 0)  { continue; }
    if (value >= 10) { break;    }
    // ...
}
// For loops can also be used as expressions.
// Similar to while loops, when you break from a for loop,
// the else branch is not evaluated.
var sum: i32 = 0;
// The "for" loop has to provide a value, which will be the "else" value.
const result = for (items) |value| {
    if (value != null) {
        sum += value.?; // "result" will be the last "sum" value.
    }
} else 0;                  // Last value.
Switch
  • Safety :

    • The types of all branches must coerce to the type which is being switched upon. All possible values must have an associated branch - values cannot be left out. It is exhaustive.

  • Zig's switch  works as both a statement and an expression.

    • Statement :

      const expect = @import("std").testing.expect;
      
      test "switch statement" {
          var x: i8 = 10;
          switch (x) {
              -1...1 => {
                  x = -x;
              },
              10, 100 => {
                  //special considerations must be made
                  //when dividing signed integers
                  x = @divExact(x, 10);
              },
              else => {},
          }
          try expect(x == 1);
      }
      
    • Expression :

      const expect = @import("std").testing.expect;
      
      test "switch expression" {
          var x: i8 = 10;
          x = switch (x) {
              -1...1 => -x,
              10, 100 => @divExact(x, 10),
              else => x,
          };
          try expect(x == 1);
      }
      
  • Cases cannot fall through to other branches.

Labelled
  • Blocks

    • The value of an empty block {}  is a value of the type void .

    const expect = @import("std").testing.expect;
    
    test "int-float conversion" {
        const a: i32 = 0;
        const b = @as(f32, @floatFromInt(a));
        const c = @as(i32, @intFromFloat(b));
        try expect(c == a);
    }
    
    • This can be seen as being equivalent to C's i++ .

    blk: {
        const tmp = i;
        i += 1;
        break :blk tmp;
    }
    
  • Loops :

    • Loops can be given labels, allowing you to break  and continue  to outer loops.

    test "nested continue" {
        var count: usize = 0;
        outer: for ([_]i32{ 1, 2, 3, 4, 5, 6, 7, 8 }) |_| {
            for ([_]i32{ 1, 2, 3, 4, 5 }) |_| {
                count += 1;
                continue :outer;
            }
        }
        try expect(count == 8);
    }
    
Iterators

Imports

  • The built-in function @import  takes in a file, and gives you a struct type based on that file.

  • All declarations labelled as pub  (for public) will end up in this struct type, ready for use.

  • @import("std")  is a special case in the compiler, and gives you access to the standard library.

Types

Casting

@prtCast
  • We can use @ptrCast  to create a new variable that points to the same location but as a different type.

  • Ex1 :

    const std = @import("std");
    
    const User = struct {
      id: u32,
      name: []const u8,
    };
    
    const Node = struct {
      next: ?*Node,
    };
    
    pub fn main() !void {
      var user1 = User{.id = 1, .name = "Leto"};
      const node1: *Node = @ptrCast(&user1);
      node1.next = null;
      std.debug.print("{}\n", .{node1});
    }
    
    • This code not only compiles, but it also runs. Compiling and running are two distinct aspects we must consider. The code compiles because we told the compiler it was ok to treat the memory as a *Node . @ptrCast  isn't changing the memory at runtime, it's forcing the compiler to see the memory as a *Node . In this case, the code runs because there are some truths we can rely on that make it so the memory used to represent a User  can safely be used to represent a Node .

  • Ex2 :

    const std = @import("std");
    
    const User = struct {
      id: u32,
      name: []const u8,
    };
    
    const Node = struct {
      next: ?*Node,
    };
    
    pub fn main() !void {
      var node1 = Node{.next = null};
      const user: *User = @ptrCast(&node1);
    
      std.debug.print("Id: {d}\n", .{user.id});
      std.debug.print("Name: {d}\n", .{user.name});
    }
    
    • Now we're creating a Node  and telling the compiler to see the underlying memory as a User . Again, this code compiles. But what happens when we try to run it? You'll probably get the same thing I did: Id: 0  followed by a segfault.

    • Why does it work one way but not the other? Consider the size of a Node  and the size of a User :

    const std = @import("std");
    pub fn main() !void {
      std.debug.print("Node: {d}   User: {d}\n", .{@sizeOf(Node), @sizeOf(User)});
    }
    
    • Assuming you're on a modern platform, you'll likely see: Node: 8 User: 24 .

    • This highlights the power and danger  of @ptrCast : it's obvious that the memory underlying a Node  isn't big enough to represent a whole User , but @ptrCast  forces the compiler to proceed as though it can.

    • But size constraints aren't the only issue. Let's go back to our original example and add 2 more lines at the end:

    const std = @import("std");
    
    const User = struct {
      id: u32,
      name: []const u8,
    };
    
    const Node = struct {
      next: ?*Node,
    };
    
    pub fn main() !void {
      var user1 = User{.id = 1, .name = "Leto"};
      const node1: *Node = @ptrCast(&user1);
      node1.next = null;
    
      std.debug.print("{}\n", .{node1});
      std.debug.print("{d}\n", .{user1.id});    // added
      std.debug.print("{s}\n", .{user1.name});  // added
    }
    
    • The underlying memory for node1  is more than big enough, but the code still crashes. When we write to user.id  or user1.name , the compiler enforces correctness: id  must be an u32  and name  must be a []const u8 . Similarly, when we write null  to node1.next , the code compiles because null  is a valid ?*Node . But when, at runtime, we try to interpret that null  as a part of a User , the behavior becomes undefined (i.e. we'll most likely crash).

  • Cautions :

    • One last thing worth pointing out is that, unless a structure is declared as packed , Zig makes no guarantee about its memory layout.

    • In almost all cases, you should not write to memory as one type and read it as another (which is exactly what we've done throughout the post).

    • Unless the struct is packed  or the struct is very simple, you cannot predict how those read/writes will be interpreted by different types sharing the same memory.

Primitives

Integers
const decimal_int: i32 = 98222;
const hex_int: u8 = 0xff;
const another_hex_int: u8 = 0xFF;
const octal_int: u16 = 0o755;
const binary_int: u8 = 0b11110000;
const one_billion: u64 = 1_000_000_000;
const binary_mask: u64 = 0b1_1111_1111;
const permissions: u64 = 0o7_5_5;
const big_address: u64 = 0xFF80_0000_0000_0000;
  • Coercion / Casting :

    const expect = @import("std").testing.expect;
    
    test "integer widening" {
        const a: u8 = 250;
        // This is ok, providing that the new type can fit all of the values that the old type can.
        const b: u16 = a;
        const c: u32 = b;
        try expect(c == a);
    }
    
    const expect = @import("std").testing.expect;
    
    test "@intCast" {
        const x: u64 = 200;
        const y = @as(u8, @intCast(x));
        try expect(@TypeOf(y) == u8);
    }
    
  • Overflow :

    • Overflows are detectable illegal behaviour.

    • Sometimes, being able to overflow integers in a well-defined manner is a wanted behaviour.

    • .

  • Saturation :

    • Values will stick to their lower and upper bounds.

    var i: u8 = 200;   // "i" is an unsigned 8-bit integer (values: from 0 to 255)
    i  +| 100 == 255   // u8: won't go higher than 255
    i  -| 300 == 0     // unsigned, won't go lower than 0
    i  *| 2   == 255   // u8: won't go higher than 255
    i <<| 8   == 255   // u8: won't go higher than 255
    
Floats
const floating_point: f64 = 123.0E+77;
const another_float: f64 = 123.0;
const yet_another: f64 = 123.0e+77;

const hex_floating_point: f64 = 0x103.70p-5;
const another_hex_float: f64 = 0x103.70;
const yet_another_hex_float: f64 = 0x103.70P-5;
const lightspeed: f64 = 299_792_458.000_000;
const nanosecond: f64 = 0.000_000_001;
const more_hex: f64 = 0x1234_5678.9ABC_CDEFp-10;
  • Coercion / Casting :

    • Floats coerce to larger float types.

    const expect = @import("std").testing.expect;
    
    test "float widening" {
        const a: f16 = 0;
        const b: f32 = a;
        const c: f128 = b;
        try expect(c == @as(f128, a));
    }
    
    • @floatFromInt

      • Is always safe

    • @intFromFloat

      • Is detectable illegal behaviour if the float value cannot fit in the integer destination type.

    const expect = @import("std").testing.expect;
    
    test "int-float conversion" {
        const a: i32 = 0;
        const b = @as(f32, @floatFromInt(a));
        const c = @as(i32, @intFromFloat(b));
        try expect(c == a);
    }
    

Generics

types
  • A function can return any type, not just primitives and arrays.

  • types  must always be compile-time known.

Examples
  • Returns an Array (new array type) :

    const std = @import("std");
    
    pub fn main() !void {
        var arr: IntArray(3) = undefined;
        arr[0] = 1;
        arr[1] = 10;
        arr[2] = 100;
        std.debug.print("{any}\n", .{arr});
    }
    
    fn IntArray(comptime length: usize) type {
        return [length]i64;
    }
    
    • This code only worked because we declared length  as comptime . That is, we require anyone who calls IntArray  to pass a compile-time known length  parameter.

  • Returns a Struct (type) :

    const std = @import("std");
    
    pub fn main() !void {
        var arr: IntArray(3) = undefined;
        arr.items[0] = 1;
        arr.items[1] = 10;
        arr.items[2] = 100;
        std.debug.print("{any}\n", .{arr.items});
    }
    
    fn IntArray(comptime length: usize) type {
        return struct {
            items: [length]i64,
        };
    }
    
  • Receives a type and returns a Struct (type) :

    fn List(comptime T: type) type {
        return struct {
            pos: usize,
            items: []T,
            allocator: Allocator,
    
            fn init(allocator: Allocator) !List(T) {
                return .{
                    .pos = 0,
                    .allocator = allocator,
                    .items = try allocator.alloc(T, 4),
                };
            }
        };
    }
    

Tuples

Tuples
// A tuple is a list of elements, possibly of different types.

const foo = .{ "hello", true, 42 };
// foo.len == 3

Arrays

Array ( [N]T )
const a = [5]u8{ 'h', 'e', 'l', 'l', 'o' };
const b = [_]u8{ 'w', 'o', 'r', 'l', 'd' };
const c: [100]u8 = [_]u8{1} ** 100;
const array = [_]u8{ 'h', 'e', 'l', 'l', 'o' };
const length = array.len; // 5
  • Multidimensional :

    const mat4x4 = [4][4]f32{
        .{ 1, 0, 0, 0 },
        .{ 0, 1, 0, 1 },
        .{ 0, 0, 1, 0 },
        .{ 0, 0, 0, 1 },
    };
    
    // Access the 2D array then the inner array through indexes.
    try expect(mat4x4[1][1] == 1.0);
    
    // Here we iterate with for loops.
    for (mat4x4) |row, row_index| {
        for (row) |cell, column_index| {
            // ...
        }
    }
    
ArrayList ( std.ArrayList(T) )
  • ArrayList .

  • Serves as a buffer that can change size.

  • Similarities :

    • std.ArrayList(T)  is similar to C++'s std::vector<T>  and Rust's Vec<T> .

  • Memory :

    • The deinit()  method frees all memory used by the ArrayList.

    • Memory can be read from and written to via its slice field - .items .

const std = @import("std");
const expect = std.testing.expect;

const eql = std.mem.eql;
const ArrayList = std.ArrayList;
const test_allocator = std.testing.allocator;

test "arraylist" {
    var list = ArrayList(u8).init(test_allocator);
    defer list.deinit();
    try list.append('H');
    try list.append('e');
    try list.append('l');
    try list.append('l');
    try list.append('o');
    try list.appendSlice(" World!");

    try expect(eql(u8, list.items, "Hello World!"));
}

Vectors

  • Allow efficient parallel operations using SIMD (Single Instruction, Multiple Data)  instructions.

  • A data type that stores multiple values of the same type.

    • Vectors can only have child types of booleans, integers, floats and pointers.

  • Note that using explicit vectors may result in slower code if you make wrong choices. The compiler's auto-vectorization is fairly smart.

  • Access :

    • Vectors are indexable.

    const expect = @import("std").testing.expect;
    
    test "vector indexing" {
        const x: @Vector(4, u8) = .{ 255, 0, 255, 0 };
        try expect(x[0] == 255);
    }
    
  • Operations :

    • Addition :

      const expect = @import("std").testing.expect;
      
      const meta = @import("std").meta;
      
      test "vector add" {
          const x: @Vector(4, f32) = .{ 1, -10, 20, -1 };
          const y: @Vector(4, f32) = .{ 2, 10, 0, 1 };
          const z = x + y;
          try expect(meta.eql(z, @Vector(4, f32){ 3, 0, 20, 0 }));
      }
      
      const a: @Vector(4, i32) = @Vector(4, i32){ 1, 2, 3, 4 };
      const b: @Vector(4, i32) = @Vector(4, i32){ 10, 20, 30, 40 };
      
      const c = a + b; // Result: {11, 22, 33, 44}
      
      
    • Scalar Multiply :

      • The function @splat(len, value)  creates a vector filled with the same value.

      const v: @Vector(4, i32) = @Vector(4, i32){ 2, 4, 6, 8 };
      const scale = 2;
      
      const result = v * @splat(4, scale); // {4, 8, 12, 16}
      
      
  • Coercion :

    • Vectors coerce to their respective arrays.

    const arr: [4]f32 = @Vector(4, f32){ 1, 2, 3, 4 };
    

Strings

// Simple string constant.
const greetings = "hello";
// ... which is equivalent to:
const greetings: *const [5:0]u8 = "hello";
// In words: "greetings" is a constant value, a pointer to a constant array of 5 elements (8-bit unsigned integers), with an extra '0' at the end.
// The extra "0" is called a "sentinel value".

print("string: {s}\n", .{greetings});
String Literals
  • The type of string literals is *const [N:0]u8 , where N is the length of the string.

    • This allows string literals to coerce to sentinel-terminated slices, and sentinel-terminated many pointers.

const expect = @import("std").testing.expect;

test "string literal" {
    try expect(@TypeOf("hello") == *const [5:0]u8);
}
Concatenation
  • With Alloc :

    const std = @import("std");
    
    pub fn main() !void {
        const name = "Leto";
        
        const say = std.fmt.allocPrint(allocator, "Hello {s}", .{name});
        defer allocator.free(say);
        
        std.debug.print("{s}\n", .{greeting});
    
  • With buffer :

    • This API moves the memory management burden to the caller. If we had a longer name , or a smaller buf , our bufPrint  could return a NoSpaceLeft  error.

    • But there are plenty of scenarios where an application has known limits, such as a maximum name length.

    • In those cases bufPrint  is safer and faster.

    const std = @import("std");
    
    pub fn main() !void {
        const name = "Leto";
    
        var buf: [100]u8 = undefined;
        const greeting = try std.fmt.bufPrint(&buf, "Hello {s}", .{name});
    
        std.debug.print("{s}\n", .{greeting});
    }
    
Equal
const internalIcons = "Internal_Icons";
if (std.mem.eql(u8, internalIcons, "Internal_Icons")) continue;
Contains
Copy
C Strings
  • [*:0]u8  and [*:0]const u8  perfectly model C's strings.

const expect = @import("std").testing.expect;

test "C string" {
    const c_string: [*:0]const u8 = "hello";
    var array: [5]u8 = undefined;

    var i: usize = 0;
    while (c_string[i] != 0) : (i += 1) {
        array[i] = c_string[i];
    }
}

Sentinel Termination

  • [N:t]T , [:t]T , and [*:t]T , where t  is a value of the child type T .

const expect = @import("std").testing.expect;

test "sentinel termination" {
    const terminated = [3:0]u8{ 3, 2, 1 };
    try expect(terminated.len == 3);
    try expect(@as(*const [4]u8, @ptrCast(&terminated))[3] == 0);
        // `@ptrCast` is used to perform an unsafe type conversion. This shows us that the last element of the array is followed by a 0 byte.
}
  • Coercion :

    • Sentinel-terminated types coerce to their non-sentinel-terminated counterparts.

    const expect = @import("std").testing.expect;
    
    test "coercion" {
        const a: [*:0]u8 = undefined;
        const b: [*]u8 = a;
    
        const c: [5:0]u8 = undefined;
        const d: [5]u8 = c;
    
        const e: [:0]f32 = undefined;
        const f: []f32 = e;
    
        _ = .{ b, d, f }; // ignore unused
    }
    
  • Sentinel Terminated Slicing :

    • Can be used to create a sentinel-terminated slice with the syntax x[n..m:t] , where t  is the terminator value.

    • Doing this is an assertion from the programmer that the memory is terminated where it should be. Getting this wrong is detectable illegal behaviour.

    const expect = @import("std").testing.expect;
    
    test "sentinel terminated slicing" {
        var x = [_:0]u8{255} ** 3;
        const y = x[0..3 :0];
        _ = y;
    }
    

HashMaps

  • std.StringHashMap  and std.AutoHashMap  are just wrappers for std.HashMap .

    • If these two do not fulfill your needs, using std.HashMap  directly gives you much more control.

AutoHashMap
  • std.AutoHashMap

  • Lets you easily create a hash map type from a key type and a value type.

  • These must be initialized with an allocator.

test "hashing" {
    const Point = struct { x: i32, y: i32 };

    var map = std.AutoHashMap(u32, Point).init(
        test_allocator,  // refers to `std.testing.allocator`.
    );
    defer map.deinit();

    try map.put(1525, .{ .x = 1, .y = -4 });
    try map.put(1550, .{ .x = 2, .y = -3 });
    try map.put(1575, .{ .x = 3, .y = -2 });
    try map.put(1600, .{ .x = 4, .y = -1 });

    try expect(map.count() == 4);

    var sum = Point{ .x = 0, .y = 0 };
    var iterator = map.iterator();

    while (iterator.next()) |entry| {
        sum.x += entry.value_ptr.x;
        sum.y += entry.value_ptr.y;
    }

    try expect(sum.x == 10);
    try expect(sum.y == -10);
}
  • .fetchPut

    • Puts a value in the hash map, returning  a value if there was previously a value for that key.

    test "fetchPut" {
        var map = std.AutoHashMap(u8, f32).init(
            test_allocator,
        );
        defer map.deinit();
    
        try map.put(255, 10);
        const old = try map.fetchPut(255, 100);
    
        try expect(old.?.value == 10);
        try expect(map.get(255).? == 100);
    }
    
StringHashMap
  • std.StringHashMap

  • For when you need strings as keys.

test "string hashmap" {
    var map = std.StringHashMap(enum { cool, uncool }).init(
        test_allocator,
    );
    defer map.deinit();

    try map.put("loris", .uncool);
    try map.put("me", .cool);

    try expect(map.get("me").? == .cool);
    try expect(map.get("loris").? == .uncool);
}

Enums ( enum {} )

  • Allow you to define types with a restricted set of named values.

const Direction = enum { north, south, east, west };
const Value = enum(u2) { zero, one, two };
  • Default values :

    • Enum ordinal values start at 0. They can be accessed with the built-in function @intFromEnum .

    const expect = @import("std").testing.expect;
    
    const Value = enum(u2) { zero, one, two };
    
    test "enum ordinal value" {
        try expect(@intFromEnum(Value.zero) == 0);
        try expect(@intFromEnum(Value.one) == 1);
        try expect(@intFromEnum(Value.two) == 2);
    }
    
    • Values can be overridden, with subsequent values continuing from there.

    const expect = @import("std").testing.expect;
    
    const Value2 = enum(u32) {
        hundred = 100,
        thousand = 1000,
        million = 1000000,
        next,
    };
    
    test "set enum ordinal value" {
        try expect(@intFromEnum(Value2.hundred) == 100);
        try expect(@intFromEnum(Value2.thousand) == 1000);
        try expect(@intFromEnum(Value2.million) == 1000000);
        try expect(@intFromEnum(Value2.next) == 1000001);
    }
    
  • Variables :

    • Enums can also have var  and const  declarations.

    • These act as namespaced globals and their values are unrelated to instances of the enum type.

    const expect = @import("std").testing.expect;
    
    const Mode = enum {
        var count: u32 = 0;
        on,
        off,
    };
    
    test "hmm" {
        Mode.count += 1;
        try expect(Mode.count == 1);
    }
    
  • Methods :

    const expect = @import("std").testing.expect;
    
    const Suit = enum {
        clubs,
        spades,
        diamonds,
        hearts,
        
        pub fn isClubs(self: Suit) bool {
            return self == Suit.clubs;
        }
    };
    
    test "enum method" {
        try expect(Suit.spades.isClubs() == Suit.isClubs(.spades));
    }
    
  • Casting :

    • Enums aren't integers. Convert them with a built-in.

    const Value = enum { zero, stuff, blah };
    if (@enumToInt(Value.zero)  == 0) { ... }
    if (@enumToInt(Value.stuff) == 1) { ... }
    if (@enumToInt(Value.blah)  == 2) { ... }
    

Unions

  • Define types that store one value of many possible typed fields.

  • Only one field may be active at a time.

const Result = union {
    int: i64,
    float: f64,
    bool: bool,
};

test "simple union" {
    var result = Result{ .int = 1234 };
    result.int = 11;      // valid.
    result.float = 12.34; // invalid.
}
  • Tagged Unions :

    • Are unions that use an enum to indicate which field is active.

    const expect = @import("std").testing.expect;
    
    const Tag = enum { a, b, c };
    
    const Tagged = union(Tag) { a: u8, b: f32, c: bool };
    
    test "switch on tagged union" {
        var value = Tagged{ .b = 1.5 };
        switch (value) {
            // With `|*value|` we can capture a pointer to the values instead of the values themselves, allowing us to use dereferencing to mutate the original value.
            .a => |*byte| byte.* += 1,
            .b => |*float| float.* *= 2,
            .c => |*b| b.* = !b.*,
        }
        try expect(value.b == 3);
    }
    
    • The tag type of a tagged union can also be inferred. This is equivalent to the Tagged  type above.

    const Tagged = union(enum) { a: u8, b: f32, c: bool };
    
    • void  member types can have their type omitted from the syntax. Here, none  has type void .

    const Tagged2 = union(enum) { a: u8, b: f32, c: bool, none };
    

Structs ( T{} )

  • Zig gives no guarantees about the in-memory order of fields in a struct or its size.

  • Struct fields cannot be implicitly uninitialized. If some component of the Struct is missing initialization, it will cause an error.

const Vec3 = struct { x: f32, y: f32, z: f32 };

test "struct usage" {
    const my_vector = Vec3{
        .x = 0,
        .y = 100,
        .z = 50,
    };
    _ = my_vector;
}
  • Defaults :

    const Vec4 = struct { x: f32 = 0, y: f32 = 0, z: f32 = 0, w: f32 = 0 };
    
    test "struct defaults" {
        const my_vector = Vec4{
            .x = 25,
            .y = -50,
        };
        _ = my_vector;
    }
    
  • Packed :

    // Packed structure, with guaranteed in-memory layout.
    const Divided = packed struct {
        half1: u8,
        quarter3: u4,
        quarter4: u4,
    };
    
  • Methods :

    • "Structs have the unique property that when given a pointer to a struct, one level of dereferencing is done automatically when accessing fields."

    • "In this example, self.x  and self.y  are accessed in the swap function without needing to dereference the self pointer."

    const expect = @import("std").testing.expect;
    
    const Stuff = struct {
        x: i32,
        y: i32,
        fn swap(self: *Stuff) void {
            const tmp = self.x;
            self.x = self.y;    // "without needing to dereference the self pointer", I believe this is `self.x` in Zig, whereas in C++ it would be `self->x` / `(*self).x`.
            self.y = tmp;
        }
    };
    
    test "automatic dereference" {
        var thing = Stuff{ .x = 10, .y = 20 };
        thing.swap();
        try expect(thing.x == 20);
        try expect(thing.y == 10);
    }
    
    const Point = struct {
        const Self = @This(); // Refers to its own type (later called "Point").
    
        x: u32,
        y: u32,
        // Take a look at the signature. First argument is of type *Self: "self" is
        // a pointer on the instance of the structure.
        // This allows the same "dot" notation as in OOP, like "instance.set(x,y)".
        // See the following example.
        pub fn set(self: *Self, x: u32, y: u32) void {
            self.x = x;
            self.y = y;
        }
    
        // Again, look at the signature. First argument is of type Self (not *Self),
        // this isn't a pointer. In this case, "self" refers to the instance of the
        // structure, but can't be modified.
        pub fn getx(self: Self) u32 {
            return self.x;
        }
        // PS: two previous functions may be somewhat useless.
        //     Attributes can be changed directly, no need for accessor functions.
        //     It was just an example.
    };
    
  • Anonymous Structs :

    const expect = @import("std").testing.expect;
    
    test "anonymous struct literal" {
        const Point = struct { x: i32, y: i32 };
    
        const pt: Point = .{
            .x = 13,
            .y = 67,
        };
        try expect(pt.x == 13);
        try expect(pt.y == 67);
    }
    
    const expect = @import("std").testing.expect;
    
    test "fully anonymous struct" {
        try dump(.{
            .int = @as(u32, 1234),
            .float = @as(f64, 12.34),
            .b = true,
            .s = "hi",
        });
    }
    
    fn dump(args: anytype) !void {
        try expect(args.int == 1234);
        try expect(args.float == 12.34);
        try expect(args.b);
        try expect(args.s[0] == 'h');
        try expect(args.s[1] == 'i');
    }
    
  • Files as Structs :

    • Using files as structs {11:30} .

      • Important notes:

        • const Point = @This;  as the access point.

        • Define functions as pub  if you want them accessible outside the file.

@This()
const Tea = struct {
  const Self = @This();
};

pub fn main() !void {
  // prints "true"
  std.debug.print("{}\n", .{Tea == Tea.Self});
}
Declaration
  • "The principal units of code in Zig are declarations, not expressions".

  • In Godot :

    const z := Vector2(0, 0)
    
  • Method 'a' :

    const a: rl.Vector2 = .{.x = 0, .y = 0}; 
    
    • Initializes the struct by values.

    • The method is called 'Anonymous Struct'.

    • Caio:

      • When it comes to defining default values in a struct, maybe method a  is okay, but it's still inconvenient.

      const MinhaStruct = Struct{
          // vel: rl.Vector2 = .{.x = 0, .y = 0},
          // vel: rl.Vector2 = rl.Vector2.init(0, 0),
      };
      
  • Method 'b' :

    const b = rl.Vector2.init(0, 0); 
    
    • Caio:

      • I feel a bit worried about 'initializing the struct by values', as I don't know if I'm initializing all the necessary values of the struct, so I usually go for a .init -based approach.

      • The problem is: I change the struct, but I don't get warnings from the LSP, so the only way I know I'm creating the struct wrong is when compiling. Though, if there is an init  function and I'm always using it, it feels easier on the LSP, and I don't need to compile to see the error.

      • I'm thinking more about how to avoid having to compile every time to check for type errors in places where I use the struct.

  • Method 'c' :

    const c = rl.Vector2{.x = 0, .y = 0}; 
    
    • Initializes the struct by values.

    • Quotes:

      • "c is probably least recommended followed by b."

      • "a and b are fine, c is weird".

  • Method 'd'

    const d: rl.Vector2 = .init(0, 0);
    
    • Quotes:

      • "the problem with d is that it doesn't work with catch ".

Error Handling

undefined, null, void

undefined
  • var foo: u8 = undefined .

  • Should not be thought of as no value, but as a way of telling the compiler that you are not assigning a value yet .

  • Any type may be set to undefined, but attempting to read or use that value is always  considered a mistake.

null
  • var foo: ?u8 = null; .

  • The "null" primitive value is  a value that means "no value".

  • This is typically used with optional types as in the example above.

  • When foo  equals null , that's not a value of type u8 . It means there is no value  of type u8  in foo  at all.

void
  • var foo: void = {}; .

  • "void" is a type , not a value.

  • It is the most common of the Zero Bit Types (those types which take up absolutely no space and have only a semantic value).

  • When compiled to executable code, zero bit types generate no code at all.

  • The above example shows a variable foo  of type void  which is assigned the value of an empty expression.

  • It's much more common to see void  as the return type of a function that returns nothing.

Error Sets, Try, Catch

Error Sets
  • An error set is like an enum, where each error in the set is a value.

  • There are no exceptions in Zig; errors are values.

const FileOpenError = error{
    AccessDenied,
    OutOfMemory,
    FileNotFound,
};
Union: Error
  • An error set type and another type can be combined with the !  operator to form an error union type.

  • Values of these types may be an error value  or a value of the other type .

  • If you call a function that returns an error union ( !T ) without using try  or catch , the Zig compiler will emit a compile error, since there is no defined way to handle the possible error.

  • anyerror

    • Is the global error set, which due to being the superset of all error sets, can have an error from any set coerced to it. Its usage should generally be avoided.

  • In variables :

    const maybe_error: AllocationError!u16 = 10;
        // `maybe_error` can be a `u16` or an error of type `AllocationError`.
        // `AllocationError!u16` means the type can be `AllocationError` or `u16`.
    const no_error = maybe_error catch 0;
        // If `maybe_error` contained an error, `no_error` would receive `0`.
        // Since `maybe_error` does not contain an error, `no_error` equals 10.
    
  • In functions :

    • With AnyError :

      fn mightFail(x: bool) !i32 { 
          // `!i32` means the type can be **any error** or `i32`.
          if (x) {
              return error.SomeError; 
          } 
          return 42;
      }
      
    • With ErrorSet :

      const MyErrors = error{ OutOfMemory, InvalidInput };
      
      fn example(x: bool) MyErrors!i32 {
          if (x) {
              return error.OutOfMemory;      // valid.
              //return error.InvalidInput;   // valid.
              //return error.SomeOtherError; // invalid.
          }
          return 42;
      }
      
      
  • Merge :

    const A = error{ NotDir, PathNotFound };
    const B = error{ OutOfMemory, PathNotFound };
    const C = A || B;
    
Try
  • Used to propagate errors automatically .

    • If the operation results in an error, the error will be returned to the caller.

  • Note :

    • Zig's try  and catch  are unrelated to try-catch in other languages.

    • Zig does not let us ignore error unions via _ = x; . We must unwrap it with try , catch , or if  by some means.

      • _ = try x;  or _ = x catch {};  is possible.

  • Syntax sugar for |err| :

    • try x  is shorthand for x catch |err| return err .

Catch
  • catch  is used to handle errors directly , providing an alternative value or specific handling.

    • In other words, a 'fallback value'.

    • "Could instead be a noreturn  - the type of return , while (true)  and others."

  • Basic :

    const result = mightFail(false) catch -1; // If there is an error, result is -1.
    
  • With Payload Capturing :

    fn failingFunction() error{Oops}!void {
        return error.Oops;
    }
    
    fn main() !void {
        failingFunction() catch |err| {
            return;
        };
    }
    
  • With Payload Capturing and Blocks :

    • "If you want to provide a default value with catch  after performing some logic, you can combine catch  with named Blocks :"

    • Source .

    const a: ?std.json.Parsed(std.json.Value) = parseJson(allocator, "mapa/mapa.ldtkasd") catch |err| blk: {
    
        print("File not found {}\n", .{err});
    
        break :blk null;
    
    };
    
  • With Payload Capturing and Switch :

    fn mightFail(x: bool) !i32 {
        if (x) return 42;
        return error.SomeError;
    }
    
    pub fn main() void {
        const result = mightFail(false) catch |err| switch (err) {
            error.SomeError => -1, // Convert the error to -1
            else => -999, // Capture other errors
        };
    }
    
  • My examples :

    • Potentially crashing the program if an error occurs:

      const jsonParsed = try parseJson(allocator, mapa);
      defer jsonParsed.deinit();
      
    • Trying to continue program execution if an error occurs:

      const jsonParsed: ?std.json.Parsed(std.json.Value) = parseJson(allocator, mapa) catch |err| a: {
          print("\nERROR | LDtkParser: {}, {s}\n", .{err, mapa});
          break :a null;
      };
      if (jsonParsed) |*jsonParsed_| {
          defer jsonParsed_.*.deinit();
          //etc.
      }
      

Optionals

Union: Optionals ( ?T )
  • Used to store either null  or a value of type T .

  • Unwrapping :

    • .?  is a shorthand for orelse unreachable .

      • This is used when you know it is impossible for an optional value to be null; using this to unwrap a null  value is detectable illegal behaviour.

      const expect = @import("std").testing.expect;
      
      test "orelse unreachable" {
          const a: ?f32 = 5;
          const b = a orelse unreachable;
          const c = a.?;
          try expect(b == c);
          try expect(@TypeOf(c) == f32);
      }
      
    • orelse :

      • Acts when the optional is null . This unwraps the optional to its child type.

      const expect = @import("std").testing.expect;
      
      test "orelse" {
          const a: ?f32 = null;
          const fallback_value: f32 = 0;
          const b = a orelse fallback_value;
          try expect(b == 0);
          try expect(@TypeOf(b) == f32);
      }
      
    • Unwrapping in expressions and loops :

      • All uses of Payload Capturing .

      • If :

        const a: ?i32 = 5;
        // Method 1
        if (a != null) {
            const value = a.?;
            _ = value;
        }
        // Method 2
        var b: ?i32 = 5;
        if (b) |*value| {
            value.* += 1;
        }
        
      • While :

        var numbers_left: u32 = 4;
        fn eventuallyNullSequence() ?u32 {
            if (numbers_left == 0) return null;
            numbers_left -= 1;
            return numbers_left;
        }
        
        fn main() !void {
            var sum: u32 = 0;
            while (eventuallyNullSequence()) |value| {
                sum += value;
            }
        }
        
      • As in the union example, the captured value is immutable, but we can still use a pointer capture to modify the value stored in b .

  • Note :

    • Optional pointer and optional slice types do not take up any extra memory compared to non-optional ones.

      • This is because internally they use the 0 value of the pointer for null .

Runtime Safety, Unreachable

Detectable illegal behaviour
  • Illegal Behaviours .

  • Illegal behaviour will be caught (causing a panic) with safety on, but will result in undefined behaviour with safety off.

  • Users are strongly recommended to develop and test their software with safety on, despite its speed penalties.

  • Enabled:

    test "out of bounds" {
        const a = [3]u8{ 1, 2, 3 };
        var index: u8 = 5;
        const b = a[index];
    
        _ = b;
        index = index;
    }
    
  • Disabled:

    test "out of bounds, no safety" {
        @setRuntimeSafety(false);
        const a = [3]u8{ 1, 2, 3 };
        var index: u8 = 5;
        const b = a[index];
    
        _ = b;
        index = index;
    }
    
Unreachable
  • unreachable  is an assertion  to the compiler that this statement will not be reached.

  • It can tell the compiler that a branch is impossible, which the optimiser can then take advantage of.

  • Reaching an unreachable  is detectable illegal behaviour.

test "unreachable" {
    const x: i32 = 1;
    const y: u32 = if (x == 2) 5 else unreachable;  // crashes if `unreachable` is reached.
    _ = y;
}
const expect = @import("std").testing.expect;

fn asciiToUpper(x: u8) u8 {
    return switch (x) {
        'a'...'z' => x + 'A' - 'a',
        'A'...'Z' => x,
        else => unreachable,    // crashes if `unreachable` is reached.
    };
}

test "unreachable switch" {
    try expect(asciiToUpper('a') == 'A');
    try expect(asciiToUpper('A') == 'A');
}

Memory

Lifetime and Ownership
  • Ownership determines whose responsibility it is to free the memory referenced by the pointer, and lifetime determines the point at which the memory becomes inaccessible.

  • It is the Zig programmer's responsibility to ensure that a pointer is not accessed when the memory pointed to is no longer available.

  • Note that a slice is a form of pointer, in that it references other memory.

  • Conventions :

    • In general, when a function returns a pointer, the documentation for the function should explain who "owns" the pointer. This concept helps the programmer decide when it is appropriate, if ever, to free the pointer.

      • For example, the function's documentation may say "caller owns the returned memory", in which case the code that calls the function must have a plan for when to free that memory.

      • Probably in this situation, the function will accept an Allocator  parameter.

      • The API documentation for functions and data structures should take great care to explain the ownership and lifetime semantics of pointers.

Defer

Defer
  • Defer is used to execute a statement upon exiting the current block.

  • When there are multiple defers in a single block, they are executed in reverse order.

const expect = @import("std").testing.expect;

test "defer" {
    var x: i16 = 5;
    {
        defer x += 2;
        try expect(x == 5); // first the test runs, then the defer happens.
    }
    try expect(x == 7);
}
const expect = @import("std").testing.expect;

test "multi defer" {
    var x: f32 = 5;
    {
        defer x += 2;   // runs after this one.
        defer x /= 2;   // runs first.
    }
    try expect(x == 4.5);
}
const std = @import("std");
const expect = std.testing.expect;
const print = std.debug.print;

test "defer unwinding" {
    print("\n", .{});

    defer {
        print("1 ", .{});
    }
    defer {
        print("2 ", .{});
    }
    if (false) {
        // defers are not run if they are never executed.
        defer {
            print("3 ", .{});
        }
    }
}
  • Example of handling Optionals ( ?T ) :

    const jsonParsed: ?std.json.Parsed(std.json.Value) = parseJson(allocator, mapa) catch |err| blk:
        print("\nERROR | LDtkParser: {}, {s}\n", .{err, mapa});
        break :blk null;
    };
    
    • Correct :

      defer {
          if (jsonParsed != null) {
              jsonParsed.?.deinit();
          }
          // or
          if (jsonParsed) |jsonParsed_| {
              defer jsonParsed_.deinit();
          }
          // or, (not sure which is correct)
          if (jsonParsed) |*jsonParsed_| {
              defer jsonParsed_.*.deinit();
          }
      }
      
      • The defer  will happen at the expected moment, performing actions depending on whether the variable is null.

    • Incorrect :

      if (jsonParsed != null) {
          defer jsonParsed.?.deinit();
      }
      // or
      if (jsonParsed) |jsonParsed_| {
          defer jsonParsed_.deinit();
      }
      // or, (not sure which is correct)
      if (jsonParsed) |*jsonParsed_| {
          defer jsonParsed_.*.deinit();
      }
      
      • All syntaxes are valid, but the defer  will run as soon as the if  scope exits, i.e., immediately. The defer  is executed inside the if , not outside it.

  • Comparing with Go :

    • Zig's defer  is similar to Go's, with one major difference.

    • In Zig, the defer runs at the end of its containing scope.

    • In Go, defer runs at the end of the containing function.

    • Zig's approach is probably less surprising, unless you are a Go developer.

errdefer
  • errdefer  works like defer , but only executes when the function returns with an error inside the errdefer 's block.

var problems: u32 = 98;

fn failingFunction() error{Oops}!void {
    return error.Oops;
}

fn failFnCounter() error{Oops}!void {
    errdefer problems += 1;
    try failingFunction();
}

fn main() !void {
    failFnCounter() catch |err| {
        return;
    };
}
  • Ex1 :

    const std = @import("std");
    const Allocator = std.mem.Allocator;
    
    pub const Game = struct {
        players: []Player,
        history: []Move,
        allocator: Allocator,
    
        fn init(allocator: Allocator, player_count: usize) !Game {
            var players = try allocator.alloc(Player, player_count);
            errdefer allocator.free(players);
    
            // store 10 most recent moves per player
            var history = try allocator.alloc(Move, player_count * 10);
    
            return .{
                .players = players,
                .history = history,
                .allocator = allocator,
            };
        }
    
        fn deinit(game: Game) void {
            const allocator = game.allocator;
            allocator.free(game.players);
            allocator.free(game.history);
        }
    };
    
    • Under normal conditions, players  is allocated in init  and released in deinit . But there's an edge case when the initialization of history  fails. In this case and only this case we need to undo the allocation of players .

    • Another notable aspect is that the lifecycle of our two dynamically allocated slices, players  and history , is based on application logic. There's no rule that dictates when deinit  must be called or who must call it. This is good because it gives arbitrary lifetimes, but bad because we can forget to call deinit  or call it more than once.

Comptime

  • "Compile time" is a program's environment while it is being compiled.

  • "Run time" is the environment while the compiled program executes.

  • All compiled languages perform some logic at compile time to analyze code and build symbol tables.

  • Optimizations :

    • Compilers can precompute or inline things at compile time to make the resulting program more efficient.

    • Smart compilers can even unroll loops.

    • Zig makes compile-time execution an integral part of the language.

  • Zig has a powerful comptime  feature to do things at compile time. Compile-time execution can only operate on compile-time known data. Zig provides comptime_int  and comptime_float  types. Example:

    var x = 0;
    while (true) {
      if (someCondition()) break;
      x += 2;
    }
    
    • This won't compile. x 's type is inferred as a comptime_int  since the value 0  is known at compile time. A comptime_int  must be a const . If we change to const x = 0;  we'll get a different error because we try to add 2 to a const .

    • The solution is to explicitly define x  as a runtime integer type:

      var x: usize = 0;
      
Numeric Literals
  • ALL numeric literals in Zig are of type comptime_int  or comptime_float . They are arbitrary precision.

const const_int = 12345;
const const_float = 987.654;
  • When assigned to const  identifiers, we don't need to specify sizes like u8  or f64 .

  • The values are inserted at compile time. The identifiers const_int  and const_float  don't exist in the compiled binary.

Pointers

Single-item Pointer ( *T )
  • Normal pointers in Zig cannot have 0 or null as a value.

    • Setting a *T  to 0 is detectable illegal behaviour.

  • Referencing is &variable , dereferencing is variable.* .

const expect = @import("std").testing.expect;

// The function receives a pointer to `u8`.
fn increment(num: *u8) void {
    num.* += 1;    
        // `num.*` accesses the value pointed to by the pointer (dereference).
}

test "pointers" {
    var x: u8 = 1;
    increment(&x); // Pass a pointer to `x` to `increment`.
    try expect(x == 2);
}
  • Sizes :

    • usize  and isize  have the same size as pointers.

    test "usize" {
        try expect(@sizeOf(usize) == @sizeOf(*u8));
        try expect(@sizeOf(isize) == @sizeOf(*u8));
    }
    
  • Coercion / Casting :

    • Pointers are not integers; explicit conversion is needed.

  • Recommendations :

    • Prefer slices and array types to raw pointers. Compiler-enforced types are less error-prone than pointer manipulation.

Many-item Pointer ( [*]T )
  • Many pointer types exist to represent what is pointed to: single value or array, known length or not.

  • Most programs need buffers with runtime-known lengths. Many-item pointers represent those.

  • Questions :

    • Example usage confusion:

      const expect = @import("std").testing.expect;
      
      fn doubleAllManypointer(buffer: [*]u8, byte_count: usize) void {
          var i: usize = 0;
          while (i < byte_count) : (i += 1) buffer[i] *= 2;
      }
      
      test "many-item pointers" {
          var buffer: [100]u8 = [_]u8{1} ** 100;
          const buffer_ptr: *[100]u8 = &buffer;
      
          const buffer_many_ptr: [*]u8 = buffer_ptr;
          doubleAllManypointer(buffer_many_ptr, buffer.len);
          for (buffer) |byte| try expect(byte == 2);
      
          const first_elem_ptr: *u8 = &buffer_many_ptr[0];
          const first_elem_ptr_2: *u8 = @ptrCast(buffer_many_ptr);
          try expect(first_elem_ptr == first_elem_ptr_2);
      }
      
    • "Slices can be thought of as many-item pointers ( [*]T ) plus a length ( usize )."

Slices ( []T )
  • Slices vs Arrays :

    • Slices do not store data, only a reference  to the original array.

      • They store the valid length of the buffer.

    • Slices can have runtime variable length; arrays have fixed length known at compile time.

  • Slices vs Many-item Pointers :

    • Slices are safer and more convenient. for  loops work on slices.

  • Slices are "fat pointers" and are typically twice the size of a normal pointer.

  • Slicing :

    • Create from an array with x[n..m] .

    • Slicing includes n  and excludes m .

    const expect = @import("std").testing.expect;
    
    fn total(values: []const u8) usize {
        var soma: usize = 0;
        for (values) |v| soma += v;
        return soma;
    }
    
    test "slices" {
        const array = [_]u8{ 1, 2, 3, 4, 5 };
        const slice = array[0..3];      // elements 0, 1 and 2.
        try expect(total(slice) == 6);  // returns 6 = 1 + 2 + 3.
        try expect(@TypeOf(slice) == *const [3]u8);
    }
    
    • Use x[n..]  to slice to the end.

    test "slices 3" {
        var array = [_]u8{ 1, 2, 3, 4, 5 };
        var slice = array[0..];
        _ = slice;
    }
    
Pointer Types
  • Single-item Pointer vs Multi-item Pointers :

    • .

  • .

    • []T  is a Slice.

Dangling Pointers
  • About :

    • Returning the address of a local.

  • Ex1 :

    const std = @import("std");
    
    pub fn main() !void {
      const warning1 = try powerLevel(9000);
      const warning2 = try powerLevel(10);
    
      std.debug.print("{s}\n", .{warning1});
      std.debug.print("{s}\n", .{warning2});
    }
    
    fn powerLevel(over: i32) ![]u8 {
      var buf: [20]u8 = undefined;
      return std.fmt.bufPrint(&buf, "over {d}!!!", .{over});
    }
    
    • Here we return the address of buf , but buf  ceases to exist when the function returns.

  • Ex2 :

    • Source examples .

    • Other examples:

      • Arena allocator created inside a struct, etc.

        • Not fully understood.

      • Printing a pointer that pointed to a StringHashMap entry that was removed.

        • A simple, somewhat silly example.

  • Ex3 :

    const std = @import("std");
    
    pub fn main() void {
        const user1 = User.init(1, 10);
        const user2 = User.init(2, 20);
    
        std.debug.print("User {d} has power of {d}\n", .{user1.id, user1.power});
        std.debug.print("User {d} has power of {d}\n", .{user2.id, user2.power});
    }
    
    pub const User = struct {
        id: u64,
        power: i32,
    
        fn init(id: u64, power: i32) *User{
            var user = User{
                .id = id,
                .power = power,
            };
            return &user;
        }
    };
    
    • The problem is User.init  returns the address of the local user . That's a dangling pointer. Returning &user  returns an invalid address.

    • A simple fix is to change init  to return User  (not *User ) and return user; .

      • But that's not always possible.

      • Data often must outlive function scope. For that we use the heap.

  • Ex4 :

    fn read() !void {
        const input = try readUserInput();
        return Parser.parse(input);
    }
    
    • If Parser.parse  returns a value that references input , that will be a dangling pointer. Ideally Parser  would copy input  if it needs it to live longer. There's nothing here to enforce that. Check documentation or source to know semantics.

Memory: Allocators

Injecting the Allocator
  • One of Zig's core principle is no hidden memory allocations .

    • It's a sharp contrast to what you'll find in C where memory is allocated with the standard library's malloc  function.

    • In C, if you want to know whether or not a function allocates memory, you need to read the source and look for calls to malloc .

  • The advantage of injecting the allocator isn't just explicitness, it's also flexibility.

    • std.mem.Allocator  is an interface which provides the alloc , free , create  and destroy  functions, along with a few others.

  • If you're building a library, then it's best to accept an std.mem.Allocator  and let users of your library decide which allocator implementation to use. Otherwise, you'll need to chose the right allocator.

  • Related notes when using std.json :

    • Caio: "is it a good idea to only use 1 allocator across the whole game? I don't know if this is even possible, but purely talking in terms of a centralized way of allocating and deallocating memory"

      • yes. you might end up wanting two allocators (one for general memory that you manage the lifetime of, and a seperate arena allocator that frees all its memory every frame)

    • Caio: "seems like the function is returning a huge bag of unwanted data. I mean, all I actually want is the obj, as it is inside of it that the json data is stored. Is there a way to only return the obj and not have a leak?

      • "if memory is allocated within the function, then you need a way for the caller to free it. so with std.json.parseFromSlice(), that's by calling .deinit() on the returned value. that's a pretty common pattern."

      • Caio: "if memory is allocated inside a function, return the object allocated".

    • "For a json parsing, the Parsed(T) includes an ArenaAllocator and the value - that's it. The ArenaAllocator holds all the memory for everything inside value".

Areas of memory

  • The areas are conceptual; OS and executable enforce them.

  • Global space .

  • Stack .

  • Heap .

Global Space
  • The first is global space, which is where program constants, including string literals, are stored.

  • All global data is baked into the binary, fully known at compile time (and thus runtime) and immutable.

  • This data exists throughout the lifetime of the program, never needing more or less memory.

  • Aside from the impact it has on the size of our binary, this isn't something we need to worry about at all.

Stack Allocator
  • Explanation of Allocators, focusing on Linear Allocators (Stack) .

    • Does not talk about the different types of allocators, only the Linear (Stack).

    • Provides a good visualization of how the Stack is used in functions.

    • The video is good, but not very detailed.

  • Advantages :

    • The call stack is amazing because of the simple and predictable way it manages data (by pushing and popping stack frames).

    • Automatically handled by the compiler.

    • Very fast allocation and cleanup.

  • Constraints :

    • Fixed total memory.

      • "You are not allowed to store GBs of memory on the stack, for example".

    • Fixed size.

    • Fixed lifetimes.

      • Data has a lifetime tied to its place on the call stack.

Heap Allocator
  • Useful for data that has to live beyond the rigid boundaries of function scopes.

  • We can create memory at runtime with a runtime-known size and have complete control over its lifetime.

  • It has no built-in life cycle, so our data can live for as long or as short as necessary. And that benefit is its drawback: it has no built-in life cycle, so if we don't free data, no one will.

    • You can allocate memory in an HTTP handler and free it in a background thread, two completely separate parts of the code.

  • Everything we've seen so far has been constrained by requiring an upfront size. Arrays always have a compile-time known length (in fact, the length is part of the type). All of our strings have been string literals, which have a compile-time known length.

  • Furthermore, the two types of memory management strategies we've seen, global data and the call stack, while simple and efficient, are limiting. Neither can deal with dynamically sized data and both are rigid with respect to data lifetimes.

Strategies: Heap Allocation

  • Heap Allocation Strategies .

  • Different Allocator Strategies in Zig .

    • At the time, GPA (DebugAllocator) did not exist.

    • The conclusion of the video was that "This is a developing area, but Zig is doing very well here, because it has no default allocator and forces you to think about allocator choice."

    • It wasn’t discussed which allocator to use in each case; it was only about strategies.

    • It’s strange how this area still feels so "new".

Page Allocator ("using syscalls")
  • std.heap.page_allocator; .

  • Allocates a whole page of memory each time we ask for some memory.

  • Whenever this allocator makes an allocation, it will ask your OS for entire pages of memory; an allocation of a single byte will likely reserve multiple kibibytes.

  • As asking the OS for memory requires a system call, this is also extremely inefficient for speed.

  • Very simple, very dumb, very wasteful.

  • Disadvantages :

    • "This is the base of most allocators, but it's not what people use directly".

    • Very slow, since it uses syscalls; "massive slow in your program".

    • Wasteful.

      • It doesn’t think in terms of bytes, but pages (4KB).

  • Examples :

    const std = @import("std");
    
    fn main() !void {
        const allocator = std.heap.page_allocator;
    
        const memory = try allocator.alloc(u8, 100);
            // we allocate 100 bytes as a `[]u8`.
        defer allocator.free(memory);
            // defer is used in conjunction with a free - this is a common pattern for memory management in Zig.
    }
    
  • Construction? :

    const PageAllocator = struct {
        pub fn alloc(self: *@This(), size: u32) []u8 {
            const mem = std.os.mmap( // slow (syscall)
                alignForward(size, page_size)
            ) catch {
                return error.OutOfMemory;
            }
            return mem[0..size];
        }
    
        pub fn free(self: *@This(), mem: []u8) void {
            return std.os.munmap(mem);
        }
    }
    
FixedBufferAllocator ("Bump Allocator")
  • std.heap.FixedBufferAllocator.init(...);

  • Using FixedBufferAllocator .

  • Is an allocator that allocates memory into a fixed buffer and does not make any heap allocations.

  • Uses a fixed buffer to get its memory, doesn’t ask memory from the kernel.

  • It will give you the error OutOfMemory  if it has run out of bytes.

  • Advantages :

    • Very fast allocation.

    • Control lifetime via buffer.

  • Disadvantages :

    • Fixed total memory.

    • Cannot free individual memory.

      • "There is no data structure, it only stores the last memory index. Therefore, you can’t deallocate memory in the middle of this region."

      • "Maybe it’s possible to deallocate the last allocation."

      • "It’s possible to clear the whole buffer."

      • free  and destroy  will only work on the last allocated/created item (think of a stack).

        • Freeing the non-last allocation is safe to call, but won’t do anything.

  • When to use :

    • This is useful when heap usage is not wanted, for example, when writing a kernel.

    • It may also be considered for performance reasons.

    • "If you don’t care about expandable memory, you should use FixedBufferAllocator, as it’s simply faster."

    • "Probably the fastest you’ll ever get."

  • Examples :

    const std = @import("std");
    
    fn main() !void {
        var buffer: [1000]u8 = undefined;
        var fba = std.heap.FixedBufferAllocator.init(&buffer);
        const allocator = fba.allocator();
    
        const memory = try allocator.alloc(u8, 100);
        defer allocator.free(memory);
    }
    
ArenaAllocator ("Bump Allocator with expandable memory")
  • std.heap.ArenaAllocator.init(...);

  • Using ArenaAllocator .

  • It’s the place where you store all data that share the same lifetime.

  • Takes in a child allocator and allows you to allocate many times and only free once. Use in combination with another allocator.

  • Here, .deinit()  is called on the arena, which frees all memory.

    • Using allocator.free  in this example would be a no-op (i.e., does nothing).

  • Advantages :

    • Very fast allocation.

    • Expandable total memory.

    • Manual lifetime.

      • "Arena = One Lifetime".

    • Very simple way of avoiding leaks.

  • Disadvantages :

    • Cannot free individual memory.

      • "This ends up being useful for cases like Linked Lists, for example, since it allows freeing the entire list’s memory at once without traversing it."

      • Disclaimer: "Don’t use Linked Lists, use arrays. Arrays are much faster nowadays."

  • When to use :

    • Commonly used in some places, but the problem of not being able to "free individual memory" can be annoying in some cases.

  • Examples :

    const std = @import("std");
    
    fn main() !void {
        var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
        defer arena.deinit();
        const allocator = arena.allocator();
    
        _ = try allocator.alloc(u8, 1);
        _ = try allocator.alloc(u8, 10);
        _ = try allocator.alloc(u8, 100);
    }
    
DebugAllocator (formerly GeneralPurposeAllocator (GPA))
  • std.heap.DebugAllocator(.{}){}; .

  • Using GPA .

  • "In debug builds, use DebugAllocator, formerly known as GPA. In release builds, use std.heap.smp_allocator."

  • "DebugAllocator and smp_allocator are both backed by page_allocator, which requests more memory from the operating system when it runs out."

  • Advantages :

    • Designed for safety over performance, but may still be many times faster than page_allocator.

    • This is a safe allocator that can prevent double-free, use-after-free, and detect leaks.

      • Safety checks and thread safety can be turned off via its configuration struct.

    • Thread-safe allocator.

    • Gets some memory first and manages buckets of memory to reduce the number of allocations.

  • Uses :

    • Can serve as your application's main allocator. For many programs, this will be the only allocator needed.

  • Example :

    const std = @import("std");
    const httpz = @import("httpz");
    
    pub fn main() !void {
        // create our general purpose allocator
        var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    
        // get an std.mem.Allocator from it
        const allocator = gpa.allocator();
    
        // pass our allocator to functions and libraries that require it
        var server = try httpz.Server().init(allocator, .{.port = 5882});
    
        var router = server.router();
        router.get("/api/user/:id", getUser);
    
        // blocks the current thread
        try server.listen();
    }
    
    • (2025-03-27)

      var debugAllocator = std.heap.DebugAllocator(.{}){};
          // Creates the DebugAllocator TYPE with configuration (.{})
          // Using this type, an instance is obtained by {};
          // debugAllocator is now an instance of the object `DebugAllocator(...)`.
          // It’s important that `debugAllocator` is a VAR. If you use CONST, everything crashes at .allocator() below.
      const allocator = debugAllocator.allocator();
          // The internal function is used to obtain the allocator.
          // allocator is of type `Allocator`.
      defer {
          _ = debugAllocator.deinit();
              // Not sure exactly why, but it’s important to call .deinit() on the debugAllocator.
              // Interestingly, the page_allocator does not require this.
      }
      
    • What is this: GeneralPurposeAllocator(.{}){} ?

      • std.heap.GeneralPurposeAllocator  is a function, and since it uses PascalCase, we know it returns a type.

      • .{}  is a struct initializer with an implicit type. What’s the type and where are the fields? The type is std.heap.general_purpose_allocator.Config , though it isn’t directly exposed like this, which is one reason we aren’t explicit. No fields are set because the Config  struct defines defaults, which we’ll be using.

      • This is a common pattern with configuration / options.

SMP Allocator
  • ?

  • Suggested for Release builds.

Testing Allocator
  • std.testing.allocator

  • Using the Testing Allocator .

  • About :

    • This is a special allocator that only works in tests  and can detect memory leaks.

    • Currently, it’s implemented using the GeneralPurposeAllocator  with added integration in Zig’s test runner, but that’s an implementation detail.

    • The important thing is that if we use std.testing.allocator  in our tests , we can catch most memory leaks.

  • In your code, use whatever allocator is appropriate.

Discussion: Slab Allocator
  • Similar to the Arena Allocator.

  • Advantages :

    • You can manually free memory.

  • Disadvantages :

    • Allocations have fixed sizes.

    • Metadata storage is wasteful.

Discussion: General Purpose Allocator
  • About :

    • This type of allocator was discussed in this Zig talk  from June 2020. There was no GPA yet, so everything discussed in the video and the section below is speculative.

    • Not sure if DebugAllocator / GPA is related to this concept.

    • Still, the strategic discussion is interesting.

  • Free lists :

    • Advantages :

      • You can manually free memory.

    • Disadvantages :

      • Allocations have a minimum size.

      • Very slow.

      • Memory Fragmentation.

        • "Worse performance the longer your program is running".

        • "There’s no way to defragment your memory, as there are pointers going everywhere and you can’t really track them down".

    const FreeListAllocator = struct {
        root: ?*Node,
    
        fn find(self: *@This(), size: u32) ?[]u8 {
            var iter = self.root;
            while (iter) |node| : (iter = node.next) {
                if (node.size == size) {
                    self.remove(node);
                    return node.buffer();
                }
            }
            return null;
        }
    
        pub fn free(self: *@This(), mem: u32) void {
            const node = Node.init(mem);
            self.prepend(node);
        }
    }
    
  • Free lists with size buckets :

    • This solves the Fragmentation problem, since allocations have fixed sizes; kinda; "mitigated, not all gone".

    • Advantages :

      • You can manually free memory.

    • Disadvantages :

      • Allocations have a fixed size.

      • Cache pressure.

        • "You’ll probably have cache misses if you’re allocating sporadically".

        • If everything is allocated at once, there might not be cache misses, but if allocations happen occasionally, cache misses will likely occur.

        • This makes sense when you consider that although fragmentation is avoided, this solution ends up spreading allocations that happen after deallocations.

        • "This is really bad".

init(), deinit(), create(), destroy()

  • For slices: use alloc  and free .

  • For single items: use create  and destroy .

    const std = @import("std");
    const expect = std.testing.expect;
    
    test "allocator create/destroy" {
        const byte = try std.heap.page_allocator.create(u8);
        defer std.heap.page_allocator.destroy(byte);
        byte.* = 128;
    }
    

Warnings

Double Free
const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    var arr = try allocator.alloc(usize, 4);
    allocator.free(arr);
    allocator.free(arr);

    std.debug.print("This won't get printed\n", .{});
}
  • In the case of a double free, we’ll get a hard crash.

Memory Leak
fn isSpecial(allocator: Allocator, name: [] const u8) !bool {
    const lower = try allocLower(allocator, name);
    return std.mem.eql(u8, lower, "admin");
}
  • The memory created in allocLower  is never freed.

  • Not only that, but once isSpecial  returns, it can never  be freed. Once isSpecial  returns, we lose our only reference to the allocated memory, the lower  variable. The memory is gone until our process exits.

    • Damn.

  • Our function might only leak a few bytes, but if it's a long-running process and this function is called repeatedly, it will  add up and we'll eventually run out of memory.

  • Memory leaks can be insidious. It isn’t just that the root cause can be difficult to identify. Really small leaks or leaks in infrequently executed code can be even harder to detect.

Threads

  • While Zig provides more advanced ways of writing concurrent and parallel code, std.Thread  is available for making use of OS threads.

  • Threads, however, aren't particularly useful  without strategies for thread safety.

fn ticker(step: u8) void {
    while (true) {
        std.time.sleep(1 * std.time.ns_per_s);
        tick += @as(isize, step);
    }
}

var tick: isize = 0;

test "threading" {
    var thread = try std.Thread.spawn(.{}, ticker, .{@as(u8, 1)});
    _ = thread;
    try expect(tick == 0);
    std.time.sleep(3 * std.time.ns_per_s / 2);
    try expect(tick == 1);
}

File System

Json

Regex

Others

Interfaces
  • Interface Implementation .

  • Zig doesn't have nice syntactic sugar for creating interfaces.

  • One pattern for interface-like behavior are tagged unions, though that's relatively constrained compared to true interfaces.

  • Other patterns have emerged and are used throughout the standard library, such as with std.mem.Allocator .

Game Dev

Impressions
  • (2025-03-13)

  • I feel like I'm always having to fight the casting system.

    • The system isn't bad, but it's simply VERY verbose and it gets in the way when dealing with APIs, having to do transforms all the time.

    • Making a util.zig  file and defining helper functions to do the casting helps a lot.

  • Compilation times are kind of long.

    • Not absurd, but I feel it can be a problem when dealing with a very large file.

    • So far it's been okay.

  • Dealing with strings is super stressful for game dev.

    • When both strings are comptime, concatenation is okay, but when they're runtime you need to work with allocators, etc.

    • This is not a big problem, because I always avoid using strings anyway, besides that although more work, there is elegance in handling strings as [:0]const u8 .

  • Allocation is a nightmare.

    • (2025-03-28) Lifetimes are simply very complex, even for something simple.

Libraries

Render
ECS

Demos

  • Zig GameDev .

    • zig build simple_raytracer-run

      • Failed to open, does not exist.

Demo: Games
  • Tides of Revival .

    • Tides of Revival .

    • Author's channel video .

      • Seems friendly :)

    • (2025-03-10)

      • I couldn't build it.

      • 0.14.0+ is incompatible.

      • I had issues finding dependencies, etc.

      • I managed to open the file downloaded from itch.io

    • When testing :

      • The game has a long way to go '-', but at the same time it has a nice charm of a pleasant little game.

      • Reminds me of the feeling of playing a Minecraft alpha at night, kind of cool.

      • The game has no options menu and crashes when trying to resize the window.

  • Blockens .

    • (2025-03-10)

      • I only managed to build on 1.12.1.

      • The FPS is INSANELY low; like, 4 fps in the start menu and in-game.

      • The CPU got very hot.

      • The game is not worth it at all, for learning or inspiration.

Mach

About
Impressions
  • I found it a bit uncomfortable to always have a variable called 'mach_system' or 'mech_algo', being an anonymous struct containing method names.

    • From what I understand, this is a way to show Mach which kind of method symbolizes what, so they can be accessed and run  by Mach's API.

  • In this case, pub const main  symbolizes a scheduler to make certain things be called in order, etc, when calling the "main module" (I think main is the module of this script).

    • Since this script is App.zig , this pub const main  indicates the entry point that the main.zig  file (generated by Mach) will call, once everything starts.

  • .

Things made with Mach
  • Games :

    • Aftersun .

      • (2025-03-10)

        • I couldn't build it.

        • 0.13.0 is old, 0.14.0+ is incompatible.

  • Pixel art Editor :

    • Pixi .

      • (2025-03-10)

        • I couldn't build it.

        • 0.13.0 is old, 0.14.0+ is incompatible.

Godot

Impressions
  • (2025-01-18)

    • Extreme boilerplate. Not doable.

    • Very verbose.

    • I really don't like having to deal with object deinitialization.

    • "Actually I don't like usingnamespace either in its current state, but it's the simplest way I can work out to wrap OOP. The fundamental problem is that Zig doesn’t favor OOP at all, there should be some facilities to assist in doing such".

      • The idea of repeatedly using namespaces sounds strange when looking at the code.

    • My feeling is that although Zig is easier to read than Rust, it curiously seems to introduce more boilerplate than Rust, due to the way Godot Zig was implemented. Maybe that will change in the future, but I don't feel it's worth it at all.

Tests
  • (2025-01-18)

    • Did not work with Godot 4.4-beta1, but worked with Godot 4.3.

    • I can use the Godot 4.3 .exe and use the .dll in Godot 4.4-beta1 without problems.

    • Steps:

      • Inside build.zig :

      const godot_path = b.option([]const u8, "godot", "Path to Godot engine binary [default: `godot`]") orelse "C:\\Users\\caior\\Desktop\\Programas\\! Godot Launchers\\Godot_v4.3-stable_win64.exe";`
      
      • zig build